feat(plugin-sharing): expand the field sharing recipient per matched record — the services half of the #14103 ruling - #15235
Conversation
…d record The services half of the #14103 ruling (B): `expandRecipient` becomes per-record for the `field` member only. The per-record pass reads the named user-typed column off the matched row (one query for match and recipients), honours `multiple: true`, and fails closed on an empty column. The whole-rule pass (`evaluateRule`, behind the background re-grant, the boot backfill and the REST evaluate endpoint) derives per-record pairs instead of a matched-records x recipients product; the recipient-axis revoke declines `field` rules; the rule-wide switch refuses the member loudly. The bootstrap seeds `field` rules, the `sys_sharing_rule` select stores them, `defineRule` holds the recipient to the field-name grammar, and the "until the executor lands" doc sentences are retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…re-anchor the shifted system-context citations The new suite's fake engine is recorded in scripts/engine-double-contract.pinned.json (the gate's own --write), and the two elevation-read anchors the system-context page carries into sharing-rule-service.ts moved with the inserted module helpers (the census gate's own --fix; a shift, not a population change). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ext; keep the tracker id out of the runtime string The regenerated bundles carry the new select option and the two changed help strings; the zh-CN / ja-JP / es-ES leaves are translated rather than English fills, so no source-hash provenance row is left behind. The rule-wide refusal message drops its issue reference (check:doc-authoring) — the id lives in the adjacent comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 51 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 8 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin d3590d8a1f5e0d070a1a854ca1f1c0801ff19c23 && git checkout d3590d8a1f5e0d070a1a854ca1f1c0801ff19c23
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1bc3c092a118885671d85bff5259d82fe40d9ad6 fe9281910a564c069763d5116331d544f0bc5aea && git checkout -B drift-repro 1bc3c092a118885671d85bff5259d82fe40d9ad6 && git merge --no-ff fe9281910a564c069763d5116331d544f0bc5aea
node scripts/docs-audit/affected-docs.mjs --json 1bc3c092a118885671d85bff5259d82fe40d9ad6
|
…eld-sharing-recipient # Conflicts: # packages/plugins/plugin-sharing/src/sharing-rule-service.ts
… merged tree; re-sync the engine-double ledger The os-regen merge driver does not text-merge system-context.mdx; regenerated with pnpm gen:system-context-census on the committed merge as its own commit. The engine-double-contract ledger is re-written by the gate's own --write on the same tree. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…pient compose After merging #14930 into this branch the two changes share reconcile: the whole-rule pass diffs a DesiredGrantSet and attempts each grant individually. Pinned in both directions on the field kind — a refused grant is counted and the pass (with its stale-row revocations) continues on both reconcile paths; the catch stays narrow on an unrelated engine error; the rule-wide switch's refusal of a field rule carries no engine code and is never reached by a production pass. Also tidies the blank line the merge left before reconcile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Fixes #15072
The
domain:serviceshalf of the maintainer ruling on #14103 (issuecomment-5507512776, director summon #8, 2026-09-02, verbatim 「同意」 adopting B). Quoted verbatim — the one angle-bracket placeholder is spelledUSER-FIELD-NAMEhere because the GitHub body sanitizer eats angle-bracket fragments; nothing else is changed:Draft on purpose: Clause-② is
yeson this card (the declared-rule bootstrap's accept set widens — afieldrule was refused atmapRecipientType→default: return nulland is accepted now), so this PR carriesneeds:contract-reviewand does not go ready / armed / enqueued until seat-internal contract review returns PASS.Executor contract (comment 5532175167) — the seven points, as landed
fieldonly —SharingRuleService.matchRecord(wasrecordMatches(): boolean) projects the recipient column besideidfor afieldrule, so one query answers "does this record match" and "whom does it name";expandRecipientForRecordis the per-record switch and expandsfieldfrom the row.user/team/position/business_unit/unit_and_subordinateskeep the rule-wideexpandRecipient, memoised across the pass exactly as before. Pin: apositionrecipient still expands rule-wide — three matched records, onesys_user_positionread, identical holder set on every record (field-recipient.test.ts, "the ruling's explicit pin").multiple: true—usersNamedByaccepts the two stored shapes a user-valued column has (one id, or an array of ids), de-duplicated. A string is one id, never JSON to parse: the drivers hydrate multi-value columns before a row reachesengine.find, and a parse here would fossilise a driver defect into a second contract (AGENTS.md PD Add comprehensive test suite for Zod schema validation #12).null,undefined,'',[], whitespace, and a non-id value (an object) each materialise no grant; there is no match-all principal and no fallback toowner_id(pinned: the owner is asserted absent from the grantees). An emptied column revokes the grant it once earned. An empty column is deliberately not warned about — an unassigned record sharing with nobody is the declared semantics.origin/mainfcc42e6c1d:rule-hooks.tsafterUpdatehas no changed-field gating;recomputeRow→evaluateAllForRecord→ per-record expand +reconcileForRecord) and was not re-run. The pin drives the realbindRuleHooksbinding: an update whose patch carries ONLYassigneesrevokes the old user's grant and materialises the new one; widening, clearing, and a criteria-exit write are pinned too; and the bound hook set for the object is asserted to be the existing five events — nothing registered for the recipient column.bootstrap-declared-sharing-rules.tsmapRecipientTypegainscase 'field': return 'field'(pinned: a declaredfieldrule seeds with the field name asrecipientId, no skip warning; amanagerkind is still skipped as unmappable — the control).sys_sharing_rule.recipient_type(packages/plugins/plugin-sharing/src/objects/sys-sharing-rule.object.ts— the card named the file without a path; it is not underplatform-objects) gains'field', pinned as a superset ofShareRecipientType.optionsso no authorable member can be refused at the select again; therecipient_idhelp text says the value is the name of the record field for that type (admin-facing wording, no table/column/ADR names, per that object's own rule).fieldsharing recipient per record —expandRecipientreads the user field on the matched row, re-materialises on that record's own update,positionstays rule-wide (services half of #14103, ruling B) #15072 lands" sentences — retired incontent/docs/permissions/sharing-rules.mdx(found by re-grep; not on the contract's list),content/docs/permissions/permissions-matrix.mdxandcontent/docs/protocol/objectql/security.mdx(both spots). ⛔ Not the two inpackages/spec/src/security/sharing.zod.ts(:91and:262) —packages/specis a single-owner lane this dispatch forbids me to edit, and that TSDoc feeds the generatedcontent/docs/references/tree, which would widen this diff into spec-lane artifacts. They are left for the spec seat and named in the report.content/docs/releases/**untouched.managermember anywhere.defineRuleadditionally refuses afieldrecipient whoserecipientIdis not a field name (/^[a-z_][a-z0-9_]*$/, theFieldSchema.namegrammar the spec applies at parse) — that entry (RESTPOST /sharing/rules, programmatic authoring) never runs the spec schema, so without itowner.manager_id— a graph walk spelled as a value — would reach the executor and share with nobody in silence. Scoped tofield: auserrecipient keeps its opaque id (pinned).The two rule-wide call sites (comment 5535095827) — decided, each with a pin
The rule-wide
expandRecipientis now refused forfield(a thrown error, never[]): answering nobody is precisely how a rule-wide caller turns "per record" into "revoke everything". The three callers each decide explicitly:evaluateAllForRecord(per record)expandRecipientForRecord— the contract's targetevaluateRule(rule-wide; behindrevokeThenQueueRegrant→evaluateAllRulesForObject, thekernel:bootstrappedbackfill and REST evaluate)desiredGrantsForRulebuilds the desired set per kind: rule-wide members keep matched ids × one expansion;fieldreads the matched ROWS with the recipient column projected (one criteria read, no per-record round trip) and emits (record, user) pairs.reconcilenow diffs aDesiredGrantSetand no longer knows which kind it is diffing.fieldrules on every unbounded bulk write and every restart, and[]there would have revoked every grant until each record was next written (fail-closed, but wrong). Pinned: per-record pairs with no cross product, a stale cross-product grant revoked, the revoke-object →evaluateAllRulesForObjectpath restoring exactly the per-record set, idempotence, inactive-rule purge, and no "object reconcile failed" warn.revokeRuleGrantsForRetiredRecipients(rule-wide)fieldreturns 0 before the grant read. Afieldrule has no rule-wide recipient set to retire against; its recipients are re-derived by the record's own write and by the whole-rule pass.bu-tree-recompute.tsonGraphWrite, filtersrulesonBU_TREE_RECIPIENT_TYPES= {business_unit,unit_and_subordinates} before calling it. The guard makes that a contract rather than a coincidence. Pinned three ways: the field rule answers 0 and its three grants stand (the grant table is not even read); auserrule beside it still retires a stale recipient (control); the realbindBusinessUnitTreeRecomputebinding, fired on asys_business_unit_memberinsert with a BU rule and a field rule present, hands in only the BU rule.The
owner-recipient removal reasoning, as the ruling askedpackages/spec/src/security/sharing.zod.ts(SharingRuleTypedocblock):owner-type rules were removed from the authoring surface because "they depend on live team/position membership, which the static materialiser cannot track (a membership change would have to re-materialise every dependent rule)";content/docs/protocol/objectql/security.mdx"Owner-Based Sharing — removed in v17" (#1878): they "validated but never materialized a share" (ADR-0078). Thefieldmember is the shape that reasoning permits: the recipient is a column on the record, so it moves only when the record moves, and the record's own write is exactly the event the existing materialiser already re-runs on. Amanagermember would re-introduce the removed obligation (asys_userwrite the record materialiser never sees) — which is why the ruling refuses it and why nothing here walks off the row.The column must hold users (fail-closed, loud)
fieldRecipientColumnIsUsableasks the engine's schema on every call (never cached at boot — AGENTS.md "Startup registry reads"): auserfield or alookup/master_detailwithreference: 'sys_user'is read; a column of another type or one the object does not declare grants nobody and warns once per rule per cause (no-such-field/not-user-typed); a read the engine refuses (INVALID_FIELD) fails closed and warns once (read-failed). With no schema to consult the column is read on its declared semantics. Pinned in both directions, including that an unusable column still revokes what the rule once held.PM mechanism assumptions — verdicts
sys-sharing-rule.object.ts— confirmed atpackages/plugins/plugin-sharing/src/objects/.919beca:mapRecipientTypeendeddefault: return null(:101–:114) and:223counted it asskippedwith a warn; the select listed five values.H17: the delivered diff reaches
sharing-rule-service.ts,bu-tree-recompute.ts(docblock only) andbootstrap-declared-sharing-rules.ts; notsharing-service.ts— noRestart-touchnotice owed to #6736.Surface beyond the claim, declared
content/docs/permissions/system-context.mdx:140— two line anchors intosharing-rule-service.ts(:165→:241,:390→:466) moved with the inserted module helpers; rewritten bycheck:system-context-census --fix(a shift, the population is unchanged).scripts/engine-double-contract.pinned.json— the new suite's fake engine recorded bycheck:engine-double-contract --write(the gate's own instruction for new pinned coverage).packages/plugins/plugin-sharing/src/translations/*— regenerated bycheck-i18n-bundles.mjs --writefor the new select option and the two help strings; the zh-CN / ja-JP / es-ES leaves are hand-translated (not English fills)..changeset/plugin-sharing-field-recipient.md—minor: a new recipient kind is enforced by a released package.Tests
packages/plugins/plugin-sharing/src/field-recipient.test.ts— 52 pins across the seven sections above. Whole package:pnpm --filter @objectstack/plugin-sharing exec vitest run --maxWorkers=2→Test Files 33 passed (33) · Tests 853 passed (853);pnpm --filter @objectstack/plugin-sharing typecheck→check:test-typecheck: OK. Gate union and the head it was measured on: see the report comment on #15072 (os-dev-report).Out of scope, filed: objectui#7613 (the Setup recipient picker has no
fieldmode; it degrades to its text input). #14930 is not addressed here.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code